Feat/auth image collections [OWTR #1] - #548
Conversation
…geUploadData for reuse
…tter reflect that they are not cms but implementation, just like /services folder does
…er should control this field never an outside user
|
I suggest someone to user-test this that the visibility authoring makes sense |
|
@theodorklauritzen . Can you look at this? |
theodorklauritzen
left a comment
There was a problem hiding this comment.
Written by Claude (Opus 5) with minimal oversight from Theodor. This review was produced by the compound-engineering
ce-code-reviewskill: ten reviewer agents over the full diff, merged and deduplicated, then put through an independent validation pass (4 of 15 findings were dropped there — see Coverage). Treat the findings as a starting point, not a verdict from a human reviewer. Line references are against head445e6999.
Code Review -- PR #548 "Feat/auth image collections [OWTR #1]"
Scope: pr:548 (vevcom/projectNext), base main, head feat/auth-image-collections @ 445e6999, merge-base 854dbe05. 402 files, +8299 / -5533, 82 commits. Scope mode: PR-remote (local tree is on an unrelated branch; all inspection ran against the fetched PR head). No untracked files excluded.
Intent (from the PR body, explicit): split the flat images service into a sub-service plus per-domain implementations, put every dynamic collection behind a two-level visibility model (see vs administrate), and move standard images from seeded rows to config-declared files -- without weakening authorization, losing stored files, or breaking existing image consumers across the CmsImage -> Image move.
Reviewer team: correctness, project-standards (repo-root CLAUDE.md governs every changed path), security (new authorizer + per-level checks), adversarial (auth + persistence writes + file deletion), testing (two new suites, large behavior change), maintainability (13k executable changed lines, new abstractions), data-migration (Prisma schema retargets Flair.image/Committee.logoImage, SpecialCmsImage shrinks), reliability (store deletion, seed races), performance (paging + visibility filters), previous-comments (PR already has a review and two comments).
Triage Groups
| Group | Findings | Context | Preferred Resolution | Why |
|---|---|---|---|---|
| Nested calls re-run authorizers (apply queue) | #4, #7 | An operation calls another operation internally; bypassAuth defaults to false, so the inner authorizer is enforced against the end user. |
Fix #7 first (it blocks a normal member from changing their own profile picture), then #4's four readCurrent({}) sites. Both are the same one-line pattern: pass bypassAuth: true / use an internal reader. |
Same root cause, same fix shape; the PR body already documents this exact bug class as "fixed" for visibilityOperations.update. |
| Empty visibility level authorizes everyone (decision gate, then apply) | #5, #8 | checkVisibility returns true for an empty requirement list. Two independent ways to reach that state: a fresh/seeded/migrated collection (#8) and a failed visibility save (#5). |
Decide #8 first: make an empty ADMIN matrix unsatisfiable in RequireLevelFromDoubleLevelVisibility. That closes #8 for existing collections too and caps #5's blast radius. Then wrap the update in a transaction (#5). |
One design choice resolves both; fixing only #5 leaves seeded and migrated collections open. |
| Image files leak from the store (apply queue) | #10, #11 | Deleting a collection never unlinks files; deleting one image unlinks inside a DB transaction and aborts on the first missing file. | One refactor: split destroyImage into a DB part returning the fsLocations and a post-commit cleanup step that ignores ENOENT, then reuse it in destroyCollection. Do #10 first; #11 consumes it. |
Shared fix path; committeeOperations.destroy already has the correct pattern to mirror. |
| Uncached repeat reads per request (apply queue) | #9, #14 | The same rows are re-fetched several times per request in the standard-image path and the visibility-matrix path. | Wrap both read paths in React's per-request cache(). #9 first -- it is on every page load. |
Identical mechanism and fix; no request-scoped cache exists anywhere under src/services today. |
P0 -- Critical
#4 -- omegaOrderOperations.readCurrent({}) called without bypassAuth at four sites -- src/services/users/operations.ts:38 (also groups/committees/operations.ts:261, groups/interestGroups/operations.ts:16, omegaOrder/operations.ts:20)
- Why it matters: Creating a user (invitation flow), creating a committee, creating an interest group, and incrementing the omega order now throw UNAUTHORIZED for any session that holds the operation's own permission but not
OMEGA_ORDER_READ-- an admin permission, not a default or membership one. All four previously called the unauthenticated helperreadCurrentOmegaOrder(), deleted in this PR. - Fix (mechanical): add
bypassAuth: trueto each of the four calls, matchingomegaOrder/operations.ts:64in this same diff. - Confidence: 100, validator-confirmed (
bypassAuthresolves tofalsefor nested calls atserviceOperation.ts:250). Single reviewer (project-standards). - Note the irony: the PR body describes fixing exactly this bug for
visibilityOperations.update, while introducing it at four more sites.
#5 -- Visibility update deletes all requirements outside a transaction, so a failed save opens the collection to everyone -- src/services/visibility/operations.ts:23
- Why it matters:
visibilityOperations.updaterunsvisibilityRequirement.deleteManyand then a separatevisibility.updatere-create, with no transaction and noopensTransaction. Any failure in between -- a non-existentgroupId/order(both real FKs), or two conditions colliding on@@unique([visibilityRequirementId, groupId, order])-- leaves zero requirements, andcheckVisibilitytreats an empty list as "everyone". It is reachable directly throughupdateDynamicImageCollectionRegularLevel/AdminLevelVisibilityAction. - Fix (mechanical): set
opensTransaction: trueand wrap both statements in oneprisma.$transaction, mirroringimageOperations.destroyCollection. Optionally de-duplicate conditions by(groupId, order)before the write so the normal UI path never trips the unique index. - Confidence: 75, validator-confirmed. Corroborated by two independent reviewers (security, adversarial).
#7 -- Special-collection panel authorizer re-runs on internal calls, blocking self-service uploads -- src/services/images/subservice/special/implement.ts:84
- Why it matters: A normal member cannot change their own profile picture. The settings page renders the uploader (
userAuth.updateProfileImagepasses on username match), but the server call fails:uploadImageinternally callsreadCollection({}), whose authorizer isprofileImagesImagePanelAuth = RequirePermission('USERS_UPDATE').internalCallneuters only the sub-operation's own authorizer, not the nested one. The same mismatch hitscommitteeOperations.updateLogo,committeeOperations.create, andombulOperations.create. - Fix (mechanical): give
implementSpecialCollectionan internal reader (or callreadCollection({ bypassAuth: true })) foruploadImage,destroyImage, andreadPageOfImagesInCollection; keep the panel authorizer only on the externally exposedspecialCollectionPanelOperations.readCollection. - Confidence: 75, validator-confirmed. Single reviewer (correctness).
P1 -- High
#8 -- New collections start with an empty admin level, so anyone can administrate them -- src/services/images/dynamic/operations.ts:114 (decision gate -- do not auto-apply)
- Why it matters:
createCollectionmints twoVisibilityrows with zero requirements;checkVisibilityis vacuously true for an empty list;RequireLevelFromDoubleLevelVisibilityis declaredUSER_NOT_REQUIERED_FOR_AUTHORIZED. SodestroyDynamicImageCollectionActionsucceeds with no session at all. This is not only a transient window on new collections:seedImagescreates the permanent "seeded cms images" collection the same way, andmigrateImageCollectionsgives every OmegaWeb collectionvisibilityAdmin: { create: {} }under its own//TODO: not everyone should be able to update this..... - The decision (two options, different scope):
- (a) Seed
visibilityAdminfrom the creating session insidecreateCollection's transaction. Fixes new collections only. - (b) Make
RequireLevelFromDoubleLevelVisibilitytreat an empty matrix as unsatisfiable whenlevel === 'ADMIN'. Also closes seeded and migrated collections;IMAGE_ADMINstays the recovery path. This also removes the ordering constraint the PR body documents ("the admin level must be narrowed before the regular level"). - (b) is the broader fix; (a) alone leaves seeded and migrated collections open.
- (a) Seed
- Confidence: 100, validator-confirmed. Corroborated by three independent reviewers (correctness, security, adversarial) -- the strongest agreement in this review.
#9 -- Standard-image lookups fan out into ~30 uncached DB round trips on every page -- src/services/images/standard/operations.ts:106
- Why it matters:
src/app/layout.tsx:47awaitsreadAllStandardImagesAction()on every request. That loops all 14StandardImagemembers; each does afindUniqueplus astandardImagesImagePanelOperations.readCollection({})that re-resolves the sameSTANDARDIMAGEScollection every iteration. NavBar and Footer then render their ownStandardImageServercalls on top. No request-scoped cache exists anywhere undersrc/services. - Fix (mechanical): wrap the collection lookup (and ideally
readStandardImage) in React'scache(). - Confidence: 100, validator-confirmed. Single reviewer (performance).
#10 -- destroyImage unlinks files inside the DB transaction and aborts on the first missing file -- src/services/images/subservice/operations.ts:181
- Why it matters:
destroyImagedeletes the row, then unlinks four files. Callers (updateProfileImage, committeeupdateLogo) invoke it withprisma: txinsideprisma.$transaction(..., { timeout: 20000 }). Filesystem writes do not roll back, so a later failure restores theImagerow with its files already gone. Separately,implementStore.destroyFilethrowsNOT FOUNDon ENOENT, so one already-missing file aborts the remaining three unlinks. - Fix (mechanical): split into a DB-only part returning the four fsLocations plus a post-commit cleanup step; make
destroyFileswallow ENOENT; usePromise.allSettledfor the four unlinks. - Confidence: 100, validator-confirmed. Corroborated by two independent reviewers (correctness, adversarial).
#11 -- Destroying an image collection deletes the rows but leaves every file in the store -- src/services/images/subservice/operations.ts:33
- Why it matters: The confirmation dialog says "Dette vil ogsa slette alle bilder i salingen", but
destroyCollectiondeletes only theImageCollectionrow and relies ononDelete: Cascadefor theImagerows. It never callsimageStore.destroyFile, and no caller compensates. Every deletion orphans four files per image with no DB row left to find them by. Store-volume usage grows unbounded.committeeOperations.destroy(committees/operations.ts:214-218) already shows the correct pattern. - Fix (mechanical): read the collection's fsLocations before the transaction, unlink them after it commits (ignoring ENOENT), and add a test asserting the store has no residue.
- Confidence: 100, validator-confirmed. Corroborated by four independent reviewers (correctness, testing, reliability, adversarial).
#12 -- Collection cover image accepts any imageId without an ownership check -- src/services/images/subservice/operations.ts:57
- Why it matters:
updateCollectionconnectscoverImageto a caller-suppliedcoverImageIdwith no check that the image belongs to a collection the session administrates. Reading the collection back returns the fullImagerow (coverImage: truein the includer), whosefsLocation*values map onto the unauthenticated/store/images/<uuid>URLs. Iterating image IDs turns this into readout of restricted collections. The bar is low:IMAGE_COLLECTION_CREATEis a committee permission and a self-created collection makes the caller its administrator. This PR already added the equivalent guard on the sibling path (cmsImageOperations.updaterefuses animageIdunlesssessionAdministratesCollectionOfImage). - Fix (mechanical): reuse
sessionAdministratesCollectionOfImage(extract it fromcms/images/operations.ts:23), or require the image to belong to the collection being updated and throwSmorekopp('UNAUTHORIZED', ...)otherwise. - Confidence: 75, validator-confirmed. Single reviewer (security).
#13 -- Image encoding and file writes run inside the DB transaction, papered over with a 20s timeout -- src/services/users/operations.ts:580
- Why it matters:
uploadImageresizes to three sizes, avif-encodes each, and writes four files -- all insideprisma.$transaction. The author's own comment says this is "comfortably slower than the default 5000ms interactive transaction timeout under load", and the response was to raise every affected transaction to 20s. A pool connection and any held locks stay checked out across CPU-bound encoding and filesystem I/O. Same pattern inflairs.updateImage,committees.create/updateLogo, andombul.updateCoverImage. - Fix (design-shaped but concrete): two phases -- do the encode/store work outside the transaction, then open a short transaction that only writes the already-produced
fsLocation/extvalues. - Confidence: 75, validator-confirmed. Single reviewer (reliability).
P2 -- Moderate
#14 -- Double-level visibility matrix is re-fetched 2-3x per authorized request -- src/services/visibility/implement.ts:113
- Why it matters:
readDoubleLevelMatrixreads the matrix in the authorizer, then the operation body discards it and reads it again.updateRegularLevel/updateAdminLevelread it three times (authorizer,ownershipCheck,beforeRun). Every collection detail page pays for this. - Fix (mechanical): memoize
readDoubleLevelMatrixInternalwithcache(), or add a fourthAuthorizerFactorytype parameter (asRequireVisibilityFilteralready has) so the hooks receive the matrix the authorizer already computed. - Confidence: 100, validator-confirmed. Single reviewer (performance).
#15 -- Creating a collection navigates to a route that does not exist -- src/app/image-collections/MakeNewCollection.tsx:21
- Why it matters: Post-create navigation goes to
/image-collections/${collection.id}, but the only routes undersrc/app/image-collectionsaredynamic/[name]andspecial/[specialName]. Creating a collection 404s. - Fix (mechanical): navigate by name, matching
CollectionCardLink.collectionHref:/image-collections/dynamic/${encodeURIComponent(collection.name)}. - Confidence: 75, validator-confirmed. Single reviewer (adversarial).
Pre-existing (does not count toward the verdict)
src/prisma/seeder/src/dobbelOmega/migrateImages.ts:118-- the OmegaWeb image migration is still hard-capped at 10 images by a literal.slice(0, 10), after thelimitsfilter has already trimmed the set. P2. If a debug cap is wanted, drive it fromlimits.
Coverage
- 10 reviewers dispatched, 10 returned. No failures.
- Cross-model adversarial pass: not run -- the reviewed head is not the working tree (
pr-remote), where reviewers must inspect fetched refs. The adversarial lens ran via the in-processadversarial-reviewerfallback, as the routing rule requires. Its agreement with the session-model reviewers is therefore same-family corroboration, not cross-model. - Validation: one batch, all 15 merged findings validated (no shortcut skips -- no cross-model corroboration existed to license one). 11 confirmed, 4 dropped as
validated:false, all fromdata-migration:- Flair/Committee/Ombul FK retarget
CmsImage->Imagewith no id remap (was P0) visibilityReadId->visibilityRegularIdrename without@map(was P0)Ombul.paragraphIdnewly required with no backfill (was P0)SpecialCmsImagedropping enum members still referenced by live rows (was P0)- Common reason: the harm requires an incremental-DDL path that does not exist in this repo.
prisma.config.tssetsmigrations.path: ''with aTODO: Add migrations before production; production's Dockerfile/compose run only build+start with nodb pushormigrate; the sole schema-application script isseed(prisma db push --force-reset). The validator also confirmedmigrateOmbul.tsnow creates the paragraph viaparagraph: { create: {} }, and that no code at head still references the dropped enum members. See the first residual risk below -- the underlying concern is real, it just is not this PR's defect.
- Flair/Committee/Ombul FK retarget
- Suppressed by the confidence gate: 3 findings at anchor 50 (concurrent level-update races, concurrent standard-image regeneration, special-collection regeneration leaking
Visibilityrows) -- all preserved as residual risks below. - Demoted to soft buckets: 5 single-reviewer P2 advisories (2 testing-coverage, 1 maintainability, 2 reliability/adversarial).
- Quote-the-line gate: 0 findings demoted for a missing
first_evidence. - Untracked files: none excluded. Plan discovery: no plan found under
docs/plans/, so settlement suppression was not evaluated. - No learnings corpus (
docs/solutions/is empty), solearnings-researcherdid not run.agent-nativeanddeployment-verificationwere not selected.
Residual risks
- No forward-migration mechanism at all.
prisma.config.tshasmigrations.path: ''withTODO: Add migrations before production; the only schema-apply command isprisma db push --force-reset(full wipe + reseed). That is what invalidated the four schema findings above -- but it also means this PR's destructive DDL (an FK retarget, a required-column addition, an enum narrowing, a column rename) has no defined path to a populated production database. Pre-existing, and the highest-blast-radius item in this PR series. Worth an explicit deploy decision before OWTR #1 ships. - Image bytes are not behind the new visibility model. nginx serves
/store/images/<uuid>.<ext>with no authorization, so visibility hides listings and metadata only. Anyone who obtains anfsLocationkeeps access to a restricted image forever. Pre-existing, but this PR is what makes visibility look like an access-control boundary for image content. standardStoreRootis derived fromimport.meta.url(src/lib/standardStore/files.ts:10). Proven for the unbundled seeder scripts; unconfirmed onceimages/standard/operations.tspulls it into the Next.js production bundle, where the module sits at a chunk-dependent depth under.next/server/. If the three..segments miss, every runtime standard-image self-heal throws ENOENT on a public path. Needs a prod build to settle.readStandardImageisRequireNothingbut performs writes (delete + re-upload) when a standard image is missing or has escaped the standard collection. In a degraded state that is a repeatable unauthenticated write/disk-consumption path, and the delete bypassesdestroyImage, orphaning the old files. Concurrent regeneration can also collide on thestandardImageunique index.uploadImagewrites four files beforeprisma.image.create; a failed create strands them (the mirror image of #11, also reachable viauploadManyImages).- Concurrent
updateRegularLevel+updateAdminLeveleach validate the sub-visibility invariant against a stale peer level, so two concurrent saves can leave the pair violating it. implementStorejoins an unsanitizeddynamicStorePrefixinto the path, anddestroyFilenever normalizesfsLocation. No current caller passes user input, so this is latent, not exploitable today (call-site coverage is grep-only).- A failed visibility read collapses to the same
nullas "not permitted" inimage-collections/dynamic/[name]/page.tsx; an admin cannot tell a transient error from a denial, and nothing is logged. dynamicImageOperations.ownershipCheckcalls the fullreadCollectionrather than an internal call, adding an undocumented REGULAR-visibility requirement to every dynamic image mutation and surfacing NOT FOUND instead of DISSALLOWED for special collections.- Special collections are created with two blank visibilities; they currently fall closed only because
readCollection'sfindFirstOrThrowthrows insideownershipCheck-- an exception, not an authorization decision. expandImageCollectionfalls back toimages[0]withtake: 1and noorderBy, so a collection's implicit cover can change between reads.destroyImageon an image referenced byOmbul.coverImagecascades the Ombul row away (onDelete: Cascade), whileFlair.imageandCommittee.logoImageareRestrict. No reachable exploit constructed, but the asymmetry deserves a deliberate call.- The double-level matrix (group ids and omega orders for both levels) is serialized to the client for every session that can read a collection, exposing the composition of restricted groups to regular viewers.
uploadAsStandardImageis baked into the genericuploadImagesub-operation, forcinguploadAsStandardImage: nullat three unrelated call sites (maintainability, advisory).readDefaultCollectionCoverhas no fallback ifreadStandardImagefails, unlikeStandardImageServerwhich degrades gracefully.- Seed timeouts were raised 30s -> 60s with
maxWorkers: CI ? 2 : undefined, justified by sharp/avif CPU contention. Reasonable, but nothing regression-checks seed duration, so future slowdowns eat the new budget silently. implementDoubleLevelVisibilityOperationshas exactly one production consumer today; its generality is unproven until a second domain adopts it.- The only formal review on the PR is a Copilot bot review stating it could not review because the PR exceeds 300 files -- so no automated review coverage exists on this PR today. The one substantive human comment ("I suggest someone to user-test this that the visibility authoring makes sense") is unresolved and is a UX-validation ask, not a code change.
- Verified clean (not a risk): the old
VisiblityAdmintypo directory,ImageList,src/app/images/, the duplicatedstandard_store, andombul/ConfigVars.tsare all fully deleted at the PR head -- clean renames, no leftovers.
Testing gaps
- No test asserts the store is empty after
destroyCollection-- the gap that hid #11. - No test drives
visibilityOperations.updatethrough a failing re-create to assert the matrix is unchanged rather than emptied (#5). - No test asserts what an empty admin level authorizes; the suite arranges that state with
Session.empty()but never pins the resulting exposure (#8). - No test covers a non-privileged user changing their own profile image end-to-end (#7).
- No test covers
updateCollectionwith acoverImageIdfrom a collection the session cannot read (#12). - No test covers the
updateRegularLevel/updateAdminLevelownership check with another owner'svisibilityId(implement.ts:128-132,:150-154are unexercised). - No test covers
readStandardImage's two regeneration branches, orimplementSpecialCollection's auto-create-from-config path and its cross-collectiondestroyImagecheck. - No test exercises rollback of an image-update transaction to check whether replacement files are cleaned up.
- No test asserts DB query counts for
layout.tsx/ NavBar / Footer, so #9 and #14 would silently regress after a fix. - No test covers the
dobbelOmegaCmsImage->Imagemigration paths, or applies the schema diff to an already-populated database (PrismaTestEnvironmentalways starts from empty). - The
beforeRunframework hook is exercised only indirectly through its one caller; no direct test of its ordering/abort behavior.
Verdict: Not ready
Three P0s block merge, and all three are small, mechanical fixes: #7 (a normal member cannot change their own profile picture), #4 (user/committee/interest-group creation throws UNAUTHORIZED without OMEGA_ORDER_READ), and #5 (a failed visibility save empties the matrix, which means "everyone"). Fix those first -- they are one-line-per-site changes.
Then make the one design call: #8, whether an empty ADMIN visibility level should authorize everyone. Choosing option (b) -- treat an empty matrix as unsatisfiable for ADMIN -- also closes seeded and migrated collections and removes the level-ordering constraint the PR body documents as a known wart.
Everything else is mechanical: the store file leaks (#10, #11), the cover-image ownership check (#12), the per-request cache (#9, #14), the transaction split (#13), and the broken post-create redirect (#15).
Separately, and outside this PR's diff: the repo has no forward-migration mechanism, and this PR is the first to ship genuinely destructive DDL. That is what cleared the four schema findings, but it needs a deploy decision before OWTR #1 ships.
Image system rewrite: special/dynamic collections + double-level visibility
This is PR # 1 in the one week till realese series. These should be merged sequentially
Rewrites the image service from a single flat "images" service into a
sub-service with per-domain implementations, and puts every dynamic image
collection behind a two-level visibility system (who may see it, who may
administrate it) with a full admin UI.
main..HEADis 82 commits / 374 files. This is a large refactor — the sectionsbelow are ordered roughly by how much reviewer attention they need.
1. Image service: one service → sub-service + implementations
The old
src/services/images/{actions,operations,schemas,auth,types,collections/}is deleted and replaced by:
images/subservice/uploadImage,updateCollection,destroyCollection,readPageOfImagesInCollection, …)images/subservice/special/implement.tsimplementSpecialCollection()— how a domain service claims oneSpecialCollectionand gets typed operations for itimages/dynamic/images/standard/STANDARDIMAGEScollection +readStandardImage/ regeneration-from-configimages/specialPanels/Domain services now own their own image collection rather than reaching into a
shared one —
users(PROFILE_IMAGES),ombul(OMBULCOVERS),committees(COMMITTEELOGOS),
flairs(FLAIRIMAGES).Schema:
Flair.imageandCommittee.logoImagenow point atImageinsteadof
CmsImage.Article/ArticleCategory/NewsArticlemoved out of the CMSschema file to reflect that they are implementations, not CMS primitives.
Standard images are no longer seeded rows that can drift: each is declared in
StandardImageConfigwith a source file in the new top-levelstandard_store/,and
readStandardImageregenerates it from config if it is missing or hasescaped the standard collection. The
SpecialCmsImageenum shrank accordingly(
FRONTPAGE_LOGO,NOT_FOUND,LOADER_IMAGE, nav/footer buttons … are gone —those are standard images now).
Store (
src/services/store/→src/lib/store/) became a factory,implementStore(), so each service gets a namespaced store with its own allowedextensions — and deleting an image now actually deletes the files.
2. Double-level visibility
Visibilityis attached twice to everyImageCollection(
visibilityRegularId/visibilityAdminId).implementDoubleLevelVisibilityOperations()gives an owning service areadDoubleLevelMatrixplusupdateRegularLevel/updateAdminLevel, each withits own authorizer and an ownership check that a passed
visibilityIdreally isthat owner's level.
New authorizer
RequireLevelFromDoubleLevelVisibility(level: REGULAR | ADMIN,with an optional bypass permission —
IMAGE_ADMINfor images).Invariant: the admin level must always be a sub-visibility of the regular
level; an administrator who cannot see what they administrate is a broken state.
Enforced via
isSubVisibilityon the matrix the update would produce, so it ischecked before anything is written and either level can still be updated alone.
beforeRun(framework): enforcing this needed a hook, so.implement()gainedan optional
beforeRun?: BeforeRun<…>taking the same args asauthorizer/ownershipCheck. It runs after auth and ownership, before the operation, andthrows to abort. This is the general place for implementer invariants a
sub-operation cannot state on its own —
ownershipCheckstays for "does thisimplementer own the resource".
Bug fix:
visibilityOperations.updatecalledomegaOrderOperations.readCurrent({})without bypassing auth. That order is only a placeholder stored on
ACTIVEconditions, but requiring the caller to also hold
OMEGA_ORDER_READ— not adefault or membership permission — meant essentially nobody could save a
visibility change. Now
{ bypassAuth: true }, matching every other internalreadCurrentcall.3. Frontend
VisibilityAdmin— new editor for one matrix: requirements (ANDed) eachholding conditions (ORed),
ACTIVEvsORDERper condition, group/order pickers.CollectionAdminmounts it twice, once per level.DoubleLevelVisibilityDescription— human-readable "Kan se / Kan administrere",shown on the collection page. Generic over any double-level service, grouped with
VisibilityAdminunder_components/Visibility/.CollectionAdminruns one authorizer per action (upload-one, upload-many,update, destroy, update-regular, update-admin) instead of gating the whole panel
on
updateCollection. The count of auth checks now matches the count of actions.page.tsxand threaded down. Ifthat read fails it becomes
nulland the visibility button is simply hidden(rather than 404-ing the page); the remaining checks fall closed against an
unsatisfiable placeholder, so the
IMAGE_ADMINbypass still works.ImageList→ImagePanel, which serves both special and dynamic collections;CollectionCardno longer forces being a link (newCollectionCardLink);new
StandardImageServer/StandardImageClient; newClientDataproviderreplaces
DefaultPermissionsand the image-selection contexts.ImageUploaderis now just aForm. Callers decide on popup vs inline —committee logos and ombul covers render it beside the current image,
EditOverlayis no longer used for special-collection uploads, and
titleis caller-supplied.Flaircomponent into a button inthe
/admin/flairslist. (Flairhad briefly become a client component receivinga
Sessionclass instance as a prop, which crashes RSC serialization.)secondarymatched thepage background), and collection cards collapsing horizontally in the CMS image
editor (missing
flex-shrink: 0).4. Shared utils
Pulled out of services into
src/lib/groups/so they can be reused and so none ofthem throw:
inferGroupName,checkGroupValidity,groupOptions(orderOptions/findGroup).checkGroupValidityno longer throws — it returns{ valid: true, group } | { valid: false }. The throwing behaviour lives inassertGroupValidityin the service layer, which all 5 existing call sites use.orderOptionsdeduplicates the identical order-range logic thatVisibilityAdminand
UserListeach had.describeMatrixmoved toauth/visibility/next tocheckVisibility/isSubVisibility.5. Seeding
Migrated to
defineSeedOperation(context-aware, so seeders stop hand-threadingprisma/session). Fixes a standard-image race on seed, stops logging expected
NOT-FOUNDs during upsert, and makes the OmegaWeb migration use the new image system.
6. Tests
New
tests/services/visibility.test.ts(29 tests) andtests/services/dynamicImages.test.ts(21 tests) — 50 total, all passing.checkVisibility(AND across requirements, OR within one,ACTIVE vs ORDER, empty = everyone) and
isSubVisibility.visibilityOperationscreate/update/destroy: update replaces rather thanappends, ORDER conditions keep their order, cascade on destroy.
implementDoubleLevelVisibilityOperationswith no owning domain model — itsimplementationParams are just the two visibility ids. Covers both level updates,
the per-level authorizers, the ownership check (wrong level / another owner's
visibility →
DISSALLOWED), and the sub/super invariant in both directions.gates updating/destroying,
IMAGE_ADMINbypasses both, the paging filter hidescollections the session may not see,
showOnlyCollectionsSessionAdministratesfilters on the admin level, and special collections stay unreachable through the
dynamic service.